Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

12장. 맵

11장의 슬라이스는 “인덱스 번호로 값을 찾는” 자료구조였다. 하지만 실제 프로그램에서는 인덱스가 아니라 “이름” 이나 “ID” 같은 키로 값을 찾고 싶을 때가 훨씬 많다.

  • 사용자 이름 → 사용자 정보
  • 상품 코드 → 가격
  • 단어 → 등장 횟수

이런 경우에 쓰는 자료구조가 맵 (map) 이다. 다른 언어에서는 사전 (dictionary), 해시맵 (HashMap), 연관 배열 (associative array) 같은 이름으로도 불린다.

목표:

  • 맵을 선언하고 값을 넣고 꺼내기
  • 키가 있는지 안전하게 확인하기
  • 맵을 순회하기
  • nil 맵의 함정 피하기

12.1 맵이란

맵은 키-값 쌍을 모아 두는 자료구조다.

"alice" ──▶ 30
"bob"   ──▶ 25
"carol" ──▶ 42

키를 던지면 해당하는 값이 빠르게 돌아온다. 값을 찾는 데 드는 시간은 맵의 크기와 거의 무관하다. 원소가 백 개든 백만 개든 비슷한 속도라는 뜻이다.

키와 값의 타입

타입은 두 개를 지정한다.

  • 키 타입 — 무엇으로 찾을지
  • 값 타입 — 무엇이 저장되는지
map[string]int
//   └ 키    └ 값

위 타입은 “문자열 키로 정수 값을 찾는 맵” 이다.

키 타입의 조건

키는 아무 타입이나 될 수 없다. 비교 가능한 타입 만 키가 될 수 있다.

가능한 키 타입의 예:

  • string
  • int, float64 같은 숫자 타입
  • bool
  • 비교 가능한 필드들로만 이뤄진 구조체

불가능한 키 타입의 예:

  • 슬라이스 []int
  • 맵 자체
  • 함수

“비교 가능” 이란 == 연산자를 쓸 수 있다는 뜻이다. 슬라이스는 == 로 비교할 수 없기 때문에 키가 될 수 없다. 구조체 비교에 대해선 13장에서 자세히 다룬다.


12.2 선언과 초기화

세 가지 방법을 차례로 본다.

1. var 로 선언만 — nil 맵

var m map[string]int
fmt.Println(m == nil)  // true

이 상태의 맵은 nil 이다. 읽기는 가능하지만 쓰기는 패닉을 일으킨다.

fmt.Println(m["a"])  // 0 (제로값, 안전)
m["a"] = 1           // panic: assignment to entry in nil map

초보가 가장 흔히 만나는 함정이라 12.6 절에서 따로 강조한다.

2. make 로 만들기

비어 있는 사용 가능한 맵을 만들 땐 make 를 쓴다.

m := make(map[string]int)

m["alice"] = 30
m["bob"] = 25

fmt.Println(m)  // map[alice:30 bob:25]

이 시점부터 m 은 쓸 준비가 끝난 상태다. nil 이 아니다.

3. 맵 리터럴

처음부터 값을 채워서 만들 수도 있다.

ages := map[string]int{
    "alice": 30,
    "bob":   25,
    "carol": 42,
}

각 줄 끝에 쉼표가 필요하다. 마지막 항목 뒤에도 쉼표를 붙여야 한다 (gofmt 가 강제한다).

빈 맵도 리터럴로 만들 수 있다.

m := map[string]int{}

make(map[string]int) 과 사실상 같은 효과다.

방법결과
var m map[string]intnil 맵 (쓰기 불가)
m := make(map[string]int)사용 가능한 빈 맵
m := map[string]int{}사용 가능한 빈 맵
m := map[string]int{"a":1}초기 데이터 있는 맵

12.3 값 추가, 조회, 삭제

기본 세 가지 동작은 모두 한 줄로 끝난다.

추가와 수정

m := make(map[string]int)

m["alice"] = 30   // 추가
m["alice"] = 31   // 수정

키가 없으면 새로 만들고, 있으면 덮어쓴다. “있는지 확인 후 추가” 같은 분기를 따로 할 필요가 없다.

조회

age := m["alice"]
fmt.Println(age)  // 31

여기에 한 가지 의외의 동작이 있다.

없는 키를 조회하면?

에러가 나지 않는다. 대신 값 타입의 제로값 이 돌아온다.

m := map[string]int{"alice": 30}

fmt.Println(m["unknown"])  // 0

값 타입이 int 라서 0 이 돌아왔다. 값 타입이 string 이면 빈 문자열 "" 이 돌아온다.

이건 편리하지만 위험할 수도 있다. “진짜로 0 이 저장돼 있는 키” 와 “키 자체가 없는 경우” 를 구별하지 못하기 때문이다.

그래서 다음 절의 v, ok 형태가 필요해진다.

삭제

내장 함수 delete 를 쓴다.

delete(m, "alice")

키가 없어도 에러가 나지 않는다. “있으면 지우고, 없으면 그냥 두라” 는 안전한 동작이다.


12.4 키 존재 여부 확인

값 조회에 두 번째 반환값을 받으면 키가 실제로 있었는지 확인할 수 있다.

v, ok := m["alice"]
  • v — 값 (없으면 제로값)
  • ok — 키가 실제로 있었는지 (true/false)

흔한 패턴

if v, ok := m["alice"]; ok {
    fmt.Println("alice 의 나이는", v)
} else {
    fmt.Println("alice 정보가 없습니다")
}

8장에서 본 if 의 짧은 선언 형식과 결합되어 한 줄에 “조회 + 존재 확인 + 분기” 가 모두 들어간다. Go 코드에서 매우 자주 보게 되는 모양이다.

v 와 ok 의 조합 정리

상황vok
키가 있고 값이 3030true
키가 있고 값이 00true
키가 없음0 (제로값)false

값으로 0 이 들어 있는 경우와 키가 없는 경우를 구별할 수 있다는 게 핵심이다.


12.5 맵 순회 (for range)

11장의 for range 가 여기서 또 등장한다. 맵에서는 인덱스 대신 키가 나온다.

ages := map[string]int{
    "alice": 30,
    "bob":   25,
    "carol": 42,
}

for k, v := range ages {
    fmt.Println(k, v)
}

k 가 키, v 가 값이다.

키만, 값만

키만 필요하면 두 번째 변수를 빼면 된다.

for k := range ages {
    fmt.Println(k)
}

값만 필요하면 _ 를 쓴다.

for _, v := range ages {
    fmt.Println(v)
}

순회 순서는 매번 다르다

여기서 처음 보면 당황하는 특성이 있다.

for k, v := range ages {
    fmt.Println(k, v)
}

이 코드를 같은 프로그램에서 두 번 실행해도 출력 순서가 다를 수 있다. 실행할 때마다 일부러 섞기 때문이다.

// 1회차
bob 25
alice 30
carol 42

// 2회차
carol 42
bob 25
alice 30

이는 버그가 아니라 의도된 동작이다. 개발자가 “맵의 순서에 의존하지 않게” 만들기 위한 장치다.

왜 일부러 섞는지, 내부 구조와 함께 자세히 보려면 19장에서 다룬다. 지금은 “맵은 순서가 없다” 정도로 받아들이면 충분하다.

정렬된 순서로 순회하고 싶다면

키를 슬라이스로 모아 정렬한 뒤 순회하면 된다.

import "sort"

keys := make([]string, 0, len(ages))
for k := range ages {
    keys = append(keys, k)
}
sort.Strings(keys)

for _, k := range keys {
    fmt.Println(k, ages[k])
}

11장의 make([]T, 0, cap) 패턴이 자연스럽게 쓰인다. 미리 용량을 잡아 두면 append 가 재할당하지 않는다.


12.6 맵의 함정

세 가지만 기억하면 큰 사고는 막을 수 있다.

1. nil 맵에 쓰기 → 패닉

12.2 절에서 본 그 이야기다. 선언만 한 맵은 쓸 수 없다.

var m map[string]int
m["a"] = 1   // panic: assignment to entry in nil map

해결책은 단순하다.

m := make(map[string]int)
m["a"] = 1   // OK

슬라이스의 nil 은 append 가 가능했지만, 맵의 nil 은 쓰기가 불가능하다. 이 비대칭은 외워 두는 편이 빠르다.

2. 동시 접근은 안전하지 않다

여러 고루틴이 같은 맵을 동시에 읽고 쓰면 프로그램이 갑자기 죽거나 데이터가 깨질 수 있다.

// 위험: 여러 고루틴이 동시에 m 에 쓰기
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()

고루틴, 동시성, 그리고 안전하게 공유하는 방법은 22장부터 자세히 다룬다. 19장에서도 맵 내부 구조와 함께 다시 짚는다. 지금은 “혼자 쓰는 맵은 안심해도 되고, 여럿이 동시에 건드릴 거면 보호 장치가 필요하다” 정도로 기억해 둔다.

3. 맵 안의 값은 직접 수정 못 한다

이건 좀 미묘한 함정이다. 맵의 값이 구조체일 때 그 안의 필드를 바로 못 바꾼다.

type Point struct {
    X, Y int
}

m := map[string]Point{
    "a": {1, 2},
}

m["a"].X = 99  // 컴파일 에러

이유는 14~15장의 포인터와 메서드를 배운 뒤에야 완전히 와닿는다. 지금은 우회 방법만 알아 두면 된다.

p := m["a"]
p.X = 99
m["a"] = p

값을 통째로 꺼내 수정하고 다시 넣는 패턴이다.


12.7 정리

이 장에서 살펴본 내용:

  • 맵은 키-값을 모아 두는 자료구조다
  • 키는 비교 가능한 타입이어야 한다
  • var m ... 으로 만든 맵은 nil 이라 쓰기 불가
    • make 또는 리터럴로 만들어야 안전
  • 없는 키 조회는 에러 대신 제로값을 반환한다
  • v, ok := m[key] 로 키 존재 여부를 확인한다
  • delete 로 키를 지울 수 있다 (없어도 안전)
  • for range 로 순회할 때 순서는 매번 달라진다
  • 동시 접근은 따로 보호해야 한다

슬라이스와 맵. 이 두 자료구조만 익숙해져도 대부분의 일상 코드는 작성할 수 있다.

다음 장에서는 여러 값을 하나의 묶음으로 다루는 구조체 (struct) 를 본다. “사용자 한 명” 같은 단위 데이터를 표현하는 핵심 도구다.